Skip to content

feat: redis adapter for entity caching cache - #3139

Open
SkArchon wants to merge 11 commits into
mainfrom
milinda/eng-9923-adapter-for-redis
Open

feat: redis adapter for entity caching cache#3139
SkArchon wants to merge 11 commits into
mainfrom
milinda/eng-9923-adapter-for-redis

Conversation

@SkArchon

@SkArchon SkArchon commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

This PR contains a redis adapter to be used for entity caching, currently it does not have support for managing cache tags.

Summary by CodeRabbit

  • New Features

    • Added a Redis-backed entity cache with namespaced keys.
    • Added efficient batch read and write operations.
    • Added TTL support, validation, expiration handling, and partial-write error reporting.
    • Added support for Redis-compatible clients and context cancellation.
  • Bug Fixes

    • Improved batch validation to reject invalid entries without storing partial results.
  • Tests

    • Added comprehensive coverage for Redis caching, expiration, errors, concurrency, and cancellation.

Checklist

Open Source AI Manifesto

This project follows the principles of the Open Source AI Manifesto. Please ensure your contribution aligns with its principles.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a Redis-backed entity cache with namespaced keys, pipelined batch reads and writes, TTL handling, partial-write errors, context support, and comprehensive Miniredis tests. It also clarifies an in-memory cache test for invalid TTL validation.

Changes

Redis cache

Layer / File(s) Summary
Cache construction and interface
router/pkg/entitycaching/cache/redis/redis.go, router/pkg/entitycaching/cache/redis/redis_test.go
Adds RedisCache, nil-client validation, Redis test helpers, constructor coverage, and a compile-time interface assertion.
Pipelined batch reads
router/pkg/entitycaching/cache/redis/redis.go, router/pkg/entitycaching/cache/redis/redis_test.go
GetMany pipelines GET and PTTL, preserves caller keys, omits misses and expired entries, clones values, and reports Redis errors. Tests cover mapping, duplicates, expiry, TTL races, and failures.
Validated pipelined writes
router/pkg/entitycaching/cache/redis/redis.go, router/pkg/entitycaching/cache/redis/redis_test.go, router/pkg/entitycaching/cache/in_memory/in_memory_test.go
SetMany validates TTLs before execution, prefixes keys, tracks partial writes, and supports context cancellation. Tests cover overwrites, validation, failures, expiry, and the clarified invalid-TTL case.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding a Redis adapter for entity caching.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the router label Aug 5, 2026
@SkArchon SkArchon changed the title feat: redis adapter feat: redis adapter for entity caching cache Aug 5, 2026
@SkArchon
SkArchon marked this pull request as ready for review August 5, 2026 19:59
@SkArchon
SkArchon requested a review from a team as a code owner August 5, 2026 19:59
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Router image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-0628c3794a64cf4ff2ea977051f29c191e23ddf5

@SkArchon
SkArchon force-pushed the milinda/eng-9923-adapter-for-redis branch from 3845440 to 3562724 Compare August 5, 2026 20:01
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.47%. Comparing base (f8f0a76) to head (c091feb).

Files with missing lines Patch % Lines
router/pkg/entitycaching/cache/redis/redis.go 96.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3139      +/-   ##
==========================================
+ Coverage   62.40%   62.47%   +0.06%     
==========================================
  Files         263      264       +1     
  Lines       31048    31098      +50     
==========================================
+ Hits        19376    19428      +52     
+ Misses      10162    10159       -3     
- Partials     1510     1511       +1     
Files with missing lines Coverage Δ
router/pkg/entitycaching/cache/redis/redis.go 96.00% <96.00%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dkorittki

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
router/pkg/entitycaching/cache/redis_test.go (1)

203-218: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

This test does not test what its name says.

The subtest is named "cache tags are accepted and ignored". The item carries no tag field, and no assertion refers to tags. The body is identical to "namespaces keys with the prefix" at Lines 108-119.

Either remove the subtest, or add a real tag field to the item once the cache item type exposes one.

♻️ Proposed removal
-		t.Run("cache tags are accepted and ignored", func(t *testing.T) {
-			t.Parallel()
-
-			c, mr := newTestRedisCache(t)
-
-			err := c.SetMany(ctx, []enginecache.Item{
-				{
-					Key:   "a",
-					Value: []byte("value"),
-					TTL:   time.Minute,
-				},
-			})
-			require.NoError(t, err)
-
-			require.Equal(t, []string{testPrefix + "a"}, mr.Keys())
-		})
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/entitycaching/cache/redis_test.go` around lines 203 - 218, Remove
the redundant “cache tags are accepted and ignored” subtest from the Redis cache
tests, since its item has no tags and it duplicates the existing
namespace-prefix coverage.
router/pkg/entitycaching/cache/redis.go (1)

47-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Error context is inconsistent between the two failure paths.

If Exec reports a command error other than redis.Nil, GetMany returns it unwrapped. The per-command loop below wraps the same class of error with the failing key. The result is that identical Redis failures produce different messages depending on whether a redis.Nil miss appears earlier in the batch. The test at redis_test.go Lines 374-388 depends on that ordering detail to get the key into the message.

Consider dropping the Exec error early return for command-level failures and letting the per-command loop classify every error, so the key is always present. Keep a check for transport-level failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/entitycaching/cache/redis.go` around lines 47 - 50, Update GetMany
so the pipe.Exec error handling does not return command-level failures before
the per-command loop can add the failing key; retain only the transport-level
failure check at the Exec stage. Let the existing per-command classification and
wrapping logic handle each command error consistently, including batches
containing redis.Nil misses.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@router/pkg/entitycaching/cache/redis.go`:
- Around line 52-63: Update GetMany’s successful StringCmd.Bytes() result
handling to assign bytes.Clone(value) to enginecache.Result.Value, ensuring
returned values do not alias Redis command storage while preserving the existing
error and missing-key behavior.

---

Nitpick comments:
In `@router/pkg/entitycaching/cache/redis_test.go`:
- Around line 203-218: Remove the redundant “cache tags are accepted and
ignored” subtest from the Redis cache tests, since its item has no tags and it
duplicates the existing namespace-prefix coverage.

In `@router/pkg/entitycaching/cache/redis.go`:
- Around line 47-50: Update GetMany so the pipe.Exec error handling does not
return command-level failures before the per-command loop can add the failing
key; retain only the transport-level failure check at the Exec stage. Let the
existing per-command classification and wrapping logic handle each command error
consistently, including batches containing redis.Nil misses.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e4fbd543-f34b-46c0-92d8-b9fbf1d6e9e1

📥 Commits

Reviewing files that changed from the base of the PR and between d508d07 and 3562724.

📒 Files selected for processing (2)
  • router/pkg/entitycaching/cache/redis.go
  • router/pkg/entitycaching/cache/redis_test.go

Comment thread router/pkg/entitycaching/cache/redis.go Outdated
Comment thread router/pkg/entitycaching/cache/redis.go Outdated
Comment on lines +12 to +13
// RedisCache stores entries in Redis. Redis owns expiry, so unlike
// InMemoryCache there is nothing to sweep here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't mention the in memory store here. Has nothing to do with this implementation + I always like to mention the interface this intends to implement.

Suggested change
// RedisCache stores entries in Redis. Redis owns expiry, so unlike
// InMemoryCache there is nothing to sweep here.
// RedisCache stores entries in Redis. It implements enginecache.Cache

Comment thread router/pkg/entitycaching/cache/redis.go Outdated
// InMemoryCache there is nothing to sweep here.
type RedisCache struct {
client redis.UniversalClient
prefix string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add godoc to fields as well? I.e. whats prefix good for?

Comment thread router/pkg/entitycaching/cache/redis.go Outdated
}

// A miss surfaces as redis.Nil, which is not a failure of the batch.
if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: too much happening in one line. Can you split it up?

Suggested change
if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) {
_, err := pipe.Exec(ctx)
if err != nil && !errors.Is(err, redis.Nil) {

Comment thread router/pkg/entitycaching/cache/redis.go Outdated
// There is no partial read to salvage, the whole batch fails.
return nil, fmt.Errorf("get %q: %w", keys[i], err)
}
results[i] = enginecache.Result{Value: value, Found: true}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmd.Bytes() does not copy the value but instead returns the value as an unsafe.Pointer. As you return this value its possible someone outside this package will modify the value under the hood. Better copy it.

Suggested change
results[i] = enginecache.Result{Value: value, Found: true}
results[i] = enginecache.Result{Value: bytes.Clone(value), Found: true}

func TestRedisCache(t *testing.T) {
t.Parallel()

ctx := context.Background()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use t.Context() instead

require.Empty(t, mr.Keys())
})

t.Run("one bad item rejects the whole batch", func(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test contradicts the contract defined in the godoc of SetMany where it states "An error means an unspecified subset of the items may already have been stored"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for ttl pre-validation, will rename

require.Empty(t, mr.Keys())
})

t.Run("cache tags are accepted and ignored", func(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where exactly are cache tags used in this test? I don't know much about these tags but it does not look obvious from looking at the test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test should be removed, I removed the cache tag field for now.

err := c.SetMany(ctx, []enginecache.Item{
{Key: "a", Value: []byte("value"), TTL: time.Minute},
})
require.Error(t, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use assert.Contains to broadly verify the error is about failure to reach redis

Base automatically changed from milinda/eng-9922-in-memory-adapter to main August 10, 2026 12:33
@SkArchon
SkArchon force-pushed the milinda/eng-9923-adapter-for-redis branch from 3562724 to 1df9830 Compare August 10, 2026 12:33
Moves the redis cache into its own package and brings it in line with the
updated Cache interface.

GetMany now returns map[string]Item, pairing a PTTL with every GET in the
same pipeline so each hit carries the lifetime it has left. A PTTL of -2,
-1 or 0 is treated as a miss, so a hit always has a usable TTL: the pair is
not atomic, a key with no expiry was not written by SetMany, and a
sub-millisecond remainder is nothing a caller can act on.

SetMany keeps each command alongside the item that queued it, so a failed
batch can report the keys redis actually answered in a SetManyError. That
list understates what was written and never overstates it, since a command
left carrying a transport error may have been applied with its reply lost.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
router/pkg/entitycaching/cache/in_memory/in_memory_test.go (1)

664-677: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid the busy-wait loop.

Line 664 calls GetMany continuously for up to eventuallyFor. The surrounding parallel tests can lose CPU time while this loop runs. Sleep or wait on a ticker after each successful hit.

Proposed fix
 			require.Positive(t, item.TTL)
+			time.Sleep(eventuallyTick)
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/entitycaching/cache/in_memory/in_memory_test.go` around lines 664
- 677, Update the expiration polling loop around c.GetMany to avoid continuous
busy-waiting: after each successful hit, wait briefly using a sleep or ticker
before retrying, while retaining the existing deadline, TTL assertion, and
vanished-entry checks.
router/pkg/entitycaching/cache/redis/redis_test.go (2)

823-823: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the interface assertion into redis.go.

The assertion sits in a test file, so only a test build enforces it. enginecache.Cache is defined in an external module. If an upgrade adds a method, go build ./... still succeeds and the break surfaces later. Place the assertion in redis.go.

♻️ Proposed move

In router/pkg/entitycaching/cache/redis/redis_test.go:

-var _ enginecache.Cache = (*RedisCache)(nil)

In router/pkg/entitycaching/cache/redis/redis.go, after the RedisCache declaration:

var _ enginecache.Cache = (*RedisCache)(nil)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/entitycaching/cache/redis/redis_test.go` at line 823, Move the
compile-time interface assertion `var _ enginecache.Cache = (*RedisCache)(nil)`
from the test file into `redis.go`, placing it immediately after the
`RedisCache` declaration so regular builds validate the implementation against
`enginecache.Cache`.

349-349: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the partial-write count directly

SetManyError.Error() currently contains "1 keys were known to be written", but this text is owned upstream. Assert the unwrapped SetManyError.KnownStoredKeys count instead, while retaining require.ErrorIs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/entitycaching/cache/redis/redis_test.go` at line 349, Update the
test assertion around SetManyError to unwrap the error and assert the
KnownStoredKeys count directly, rather than matching the upstream error message
text. Retain the existing require.ErrorIs assertion and verify the expected
partial-write count is 1.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@router/pkg/entitycaching/cache/redis/redis.go`:
- Line 39: Update the GetMany doc comment to describe that it returns a map
keyed by the caller-provided keys, omitting missing or expired entries; do not
claim ordered or one-result-per-key behavior. Also correct the nearby field
comment to state that results use the original caller keys and that the Redis
prefix is not stripped.

---

Nitpick comments:
In `@router/pkg/entitycaching/cache/in_memory/in_memory_test.go`:
- Around line 664-677: Update the expiration polling loop around c.GetMany to
avoid continuous busy-waiting: after each successful hit, wait briefly using a
sleep or ticker before retrying, while retaining the existing deadline, TTL
assertion, and vanished-entry checks.

In `@router/pkg/entitycaching/cache/redis/redis_test.go`:
- Line 823: Move the compile-time interface assertion `var _ enginecache.Cache =
(*RedisCache)(nil)` from the test file into `redis.go`, placing it immediately
after the `RedisCache` declaration so regular builds validate the implementation
against `enginecache.Cache`.
- Line 349: Update the test assertion around SetManyError to unwrap the error
and assert the KnownStoredKeys count directly, rather than matching the upstream
error message text. Retain the existing require.ErrorIs assertion and verify the
expected partial-write count is 1.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dacfb96a-cbd3-444f-a131-4260fa33d8cb

📥 Commits

Reviewing files that changed from the base of the PR and between 3562724 and c091feb.

📒 Files selected for processing (3)
  • router/pkg/entitycaching/cache/in_memory/in_memory_test.go
  • router/pkg/entitycaching/cache/redis/redis.go
  • router/pkg/entitycaching/cache/redis/redis_test.go

return &RedisCache{client: client, prefix: prefix}, nil
}

// GetMany returns one result per key, in the same order as keys.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the GetMany doc comment.

GetMany returns a map[string]enginecache.Item. A map has no order, and misses and expired entries are omitted, so there is not one result per key. Describe the actual contract.

The field comment at lines 21-22 has the same problem. The prefix is never stripped. Results are keyed by the caller key, which was never prefixed.

📝 Proposed doc fix
-// GetMany returns one result per key, in the same order as keys.
+// GetMany returns an entry for every key that is present with a positive
+// remaining TTL, keyed by the caller's key. Misses and expired entries are
+// absent from the map and are not an error.
 	// prefix is prepended to every key before it reaches redis, so entity cache
 	// entries stay in their own namespace and cannot collide with anything else
-	// sharing the instance. It is applied on the way in and stripped back off on
-	// the way out, so callers only ever see the keys they asked with. An empty
-	// prefix is valid and means the keys are used as they are.
+	// sharing the instance. It is applied on the way in only; results are keyed
+	// by the caller's key, so callers never see the prefix. An empty prefix is
+	// valid and means the keys are used as they are.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/pkg/entitycaching/cache/redis/redis.go` at line 39, Update the GetMany
doc comment to describe that it returns a map keyed by the caller-provided keys,
omitting missing or expired entries; do not claim ordered or one-result-per-key
behavior. Also correct the nearby field comment to state that results use the
original caller keys and that the Redis prefix is not stripped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants